aboutsummaryrefslogtreecommitdiff
path: root/src/app/(main)/websites/[websiteId]/realtime
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-24 13:09:50 +0000
committerFuwn <[email protected]>2026-01-24 13:09:50 +0000
commit396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch)
treeb9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/app/(main)/websites/[websiteId]/realtime
downloadumami-main.tar.xz
umami-main.zip
Initial commitHEADmain
Created from https://vercel.com/new
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/realtime')
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx31
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx17
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx206
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx58
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx45
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx45
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/page.tsx12
7 files changed, 414 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx
new file mode 100644
index 0000000..6e2495b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx
@@ -0,0 +1,31 @@
+import { IconLabel } from '@umami/react-zen';
+import { useCallback } from 'react';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useCountryNames, useLocale, useMessages } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+
+export function RealtimeCountries({ data }) {
+ const { formatMessage, labels } = useMessages();
+ const { locale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+
+ const renderCountryName = useCallback(
+ ({ label: code }) => (
+ <IconLabel icon={<TypeIcon type="country" value={code} />} label={countryNames[code]} />
+ ),
+ [countryNames, locale],
+ );
+
+ return (
+ <ListTable
+ title={formatMessage(labels.countries)}
+ metric={formatMessage(labels.visitors)}
+ data={data.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ renderLabel={renderCountryName}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx
new file mode 100644
index 0000000..2b9d881
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx
@@ -0,0 +1,17 @@
+import { useMessages } from '@/components/hooks';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+
+export function RealtimeHeader({ data }: { data: any }) {
+ const { formatMessage, labels } = useMessages();
+ const { totals }: any = data || {};
+
+ return (
+ <MetricsBar>
+ <MetricCard label={formatMessage(labels.views)} value={totals.views} />
+ <MetricCard label={formatMessage(labels.visitors)} value={totals.visitors} />
+ <MetricCard label={formatMessage(labels.events)} value={totals.events} />
+ <MetricCard label={formatMessage(labels.countries)} value={totals.countries} />
+ </MetricsBar>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
new file mode 100644
index 0000000..1076361
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
@@ -0,0 +1,206 @@
+import { Column, Heading, IconLabel, Row, SearchField, Text } from '@umami/react-zen';
+import Link from 'next/link';
+import { useMemo, useState } from 'react';
+import { FixedSizeList } from 'react-window';
+import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
+import { useFormat } from '@/components//hooks/useFormat';
+import { Avatar } from '@/components/common/Avatar';
+import { Empty } from '@/components/common/Empty';
+import {
+ useCountryNames,
+ useLocale,
+ useMessages,
+ useMobile,
+ useNavigation,
+ useTimezone,
+ useWebsite,
+} from '@/components/hooks';
+import { Eye, User } from '@/components/icons';
+import { FilterButtons } from '@/components/input/FilterButtons';
+import { Lightning } from '@/components/svg';
+import { BROWSERS, OS_NAMES } from '@/lib/constants';
+
+const TYPE_ALL = 'all';
+const TYPE_PAGEVIEW = 'pageview';
+const TYPE_SESSION = 'session';
+const TYPE_EVENT = 'event';
+
+const icons = {
+ [TYPE_PAGEVIEW]: <Eye />,
+ [TYPE_SESSION]: <User />,
+ [TYPE_EVENT]: <Lightning />,
+};
+
+export function RealtimeLog({ data }: { data: any }) {
+ const website = useWebsite();
+ const [search, setSearch] = useState('');
+ const { formatMessage, labels, messages, FormattedMessage } = useMessages();
+ const { formatValue } = useFormat();
+ const { locale } = useLocale();
+ const { formatTimezoneDate } = useTimezone();
+ const { countryNames } = useCountryNames(locale);
+ const [filter, setFilter] = useState(TYPE_ALL);
+ const { updateParams } = useNavigation();
+ const { isPhone } = useMobile();
+
+ const buttons = [
+ {
+ label: formatMessage(labels.all),
+ id: TYPE_ALL,
+ },
+ {
+ label: formatMessage(labels.views),
+ id: TYPE_PAGEVIEW,
+ },
+ {
+ label: formatMessage(labels.visitors),
+ id: TYPE_SESSION,
+ },
+ {
+ label: formatMessage(labels.events),
+ id: TYPE_EVENT,
+ },
+ ];
+
+ const getTime = ({ createdAt, firstAt }) => formatTimezoneDate(firstAt || createdAt, 'pp');
+
+ const getIcon = ({ __type }) => icons[__type];
+
+ const getDetail = (log: {
+ __type: string;
+ eventName: string;
+ urlPath: string;
+ browser: string;
+ os: string;
+ country: string;
+ device: string;
+ }) => {
+ const { __type, eventName, urlPath, browser, os, country, device } = log;
+
+ if (__type === TYPE_EVENT) {
+ return (
+ <FormattedMessage
+ {...messages.eventLog}
+ values={{
+ event: <b key="b">{eventName || formatMessage(labels.unknown)}</b>,
+ url: (
+ <a
+ key="a"
+ href={`//${website?.domain}${urlPath}`}
+ target="_blank"
+ rel="noreferrer noopener"
+ >
+ {urlPath}
+ </a>
+ ),
+ }}
+ />
+ );
+ }
+
+ if (__type === TYPE_PAGEVIEW) {
+ return (
+ <a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener">
+ {urlPath}
+ </a>
+ );
+ }
+
+ if (__type === TYPE_SESSION) {
+ return (
+ <FormattedMessage
+ {...messages.visitorLog}
+ values={{
+ country: <b key="country">{countryNames[country] || formatMessage(labels.unknown)}</b>,
+ browser: <b key="browser">{BROWSERS[browser]}</b>,
+ os: <b key="os">{OS_NAMES[os] || os}</b>,
+ device: <b key="device">{formatMessage(labels[device] || labels.unknown)}</b>,
+ }}
+ />
+ );
+ }
+ };
+
+ const TableRow = ({ index, style }) => {
+ const row = logs[index];
+ return (
+ <Row alignItems="center" style={style} gap>
+ <Row minWidth="30px">
+ <Link href={updateParams({ session: row.sessionId })}>
+ <Avatar seed={row.sessionId} size={32} />
+ </Link>
+ </Row>
+ <Row minWidth="100px">
+ <Text wrap="nowrap">{getTime(row)}</Text>
+ </Row>
+ <IconLabel icon={getIcon(row)}>
+ <Text style={{ maxWidth: isPhone ? '400px' : null }} truncate>
+ {getDetail(row)}
+ </Text>
+ </IconLabel>
+ </Row>
+ );
+ };
+
+ const logs = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+
+ let logs = data.events;
+
+ if (search) {
+ logs = logs.filter(({ eventName, urlPath, browser, os, country, device }) => {
+ return [
+ eventName,
+ urlPath,
+ os,
+ formatValue(browser, 'browser'),
+ formatValue(country, 'country'),
+ formatValue(device, 'device'),
+ ]
+ .filter(n => n)
+ .map(n => n.toLowerCase())
+ .join('')
+ .includes(search.toLowerCase());
+ });
+ }
+
+ if (filter !== TYPE_ALL) {
+ return logs.filter(({ __type }) => __type === filter);
+ }
+
+ return logs;
+ }, [data, filter, formatValue, search]);
+
+ return (
+ <Column gap>
+ <Heading size="2">{formatMessage(labels.activity)}</Heading>
+ {isPhone ? (
+ <>
+ <Row>
+ <SearchField value={search} onSearch={setSearch} />
+ </Row>
+ <Row>
+ <FilterButtons items={buttons} value={filter} onChange={setFilter} />
+ </Row>
+ </>
+ ) : (
+ <Row alignItems="center" justifyContent="space-between">
+ <SearchField value={search} onSearch={setSearch} />
+ <FilterButtons items={buttons} value={filter} onChange={setFilter} />
+ </Row>
+ )}
+
+ <Column>
+ {logs?.length === 0 && <Empty />}
+ {logs?.length > 0 && (
+ <FixedSizeList width="100%" height={500} itemCount={logs.length} itemSize={50}>
+ {TableRow}
+ </FixedSizeList>
+ )}
+ </Column>
+ <SessionModal websiteId={website.id} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx
new file mode 100644
index 0000000..6220c69
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx
@@ -0,0 +1,58 @@
+'use client';
+import { Grid } from '@umami/react-zen';
+import { firstBy } from 'thenby';
+import { GridRow } from '@/components/common/GridRow';
+import { PageBody } from '@/components/common/PageBody';
+import { Panel } from '@/components/common/Panel';
+import { useMobile, useRealtimeQuery } from '@/components/hooks';
+import { RealtimeChart } from '@/components/metrics/RealtimeChart';
+import { WorldMap } from '@/components/metrics/WorldMap';
+import { percentFilter } from '@/lib/filters';
+import { RealtimeCountries } from './RealtimeCountries';
+import { RealtimeHeader } from './RealtimeHeader';
+import { RealtimeLog } from './RealtimeLog';
+import { RealtimePaths } from './RealtimePaths';
+import { RealtimeReferrers } from './RealtimeReferrers';
+
+export function RealtimePage({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useRealtimeQuery(websiteId);
+ const { isMobile } = useMobile();
+
+ if (isLoading || error) {
+ return <PageBody isLoading={isLoading} error={error} />;
+ }
+
+ const countries = percentFilter(
+ Object.keys(data.countries)
+ .map(key => ({ x: key, y: data.countries[key] }))
+ .sort(firstBy('y', -1)),
+ );
+
+ return (
+ <Grid gap="3">
+ <RealtimeHeader data={data} />
+ <Panel>
+ <RealtimeChart data={data} unit="minute" />
+ </Panel>
+ <Panel>
+ <RealtimeLog data={data} />
+ </Panel>
+ <GridRow layout="two">
+ <Panel>
+ <RealtimePaths data={data} />
+ </Panel>
+ <Panel>
+ <RealtimeReferrers data={data} />
+ </Panel>
+ </GridRow>
+ <GridRow layout="one-two">
+ <Panel>
+ <RealtimeCountries data={countries} />
+ </Panel>
+ <Panel gridColumn={isMobile ? null : 'span 2'} padding="0">
+ <WorldMap data={countries} />
+ </Panel>
+ </GridRow>
+ </Grid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx
new file mode 100644
index 0000000..1f90ad8
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx
@@ -0,0 +1,45 @@
+import thenby from 'thenby';
+import { useMessages, useWebsite } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { percentFilter } from '@/lib/filters';
+
+export function RealtimePaths({ data }: { data: any }) {
+ const website = useWebsite();
+ const { formatMessage, labels } = useMessages();
+ const { urls } = data || {};
+ const limit = 15;
+
+ const renderLink = ({ label: x }) => {
+ const domain = x.startsWith('/') ? website?.domain : '';
+ return (
+ <a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
+ {x}
+ </a>
+ );
+ };
+
+ const pages = percentFilter(
+ Object.keys(urls)
+ .map(key => {
+ return {
+ x: key,
+ y: urls[key],
+ };
+ })
+ .sort(thenby.firstBy('y', -1))
+ .slice(0, limit),
+ );
+
+ return (
+ <ListTable
+ title={formatMessage(labels.pages)}
+ metric={formatMessage(labels.views)}
+ renderLabel={renderLink}
+ data={pages.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx
new file mode 100644
index 0000000..9fd4477
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx
@@ -0,0 +1,45 @@
+import thenby from 'thenby';
+import { useMessages, useWebsite } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { percentFilter } from '@/lib/filters';
+
+export function RealtimeReferrers({ data }: { data: any }) {
+ const website = useWebsite();
+ const { formatMessage, labels } = useMessages();
+ const { referrers } = data || {};
+ const limit = 15;
+
+ const renderLink = ({ label: x }) => {
+ const domain = x.startsWith('/') ? website?.domain : '';
+ return (
+ <a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
+ {x}
+ </a>
+ );
+ };
+
+ const domains = percentFilter(
+ Object.keys(referrers)
+ .map(key => {
+ return {
+ x: key,
+ y: referrers[key],
+ };
+ })
+ .sort(thenby.firstBy('y', -1))
+ .slice(0, limit),
+ );
+
+ return (
+ <ListTable
+ title={formatMessage(labels.referrers)}
+ metric={formatMessage(labels.views)}
+ renderLabel={renderLink}
+ data={domains.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/page.tsx b/src/app/(main)/websites/[websiteId]/realtime/page.tsx
new file mode 100644
index 0000000..1552196
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { RealtimePage } from './RealtimePage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <RealtimePage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Real-time',
+};